![Angular + RxJS + WebSocket で チャットアプリを作る – Subject 利用サンプル](https://devio2023-media.developers.io/wp-content/uploads/2016/11/angular_high.png)
Angular + RxJS + WebSocket で チャットアプリを作る – Subject 利用サンプル
以前、別の記事でAkka製のチャットサーバを作ったのでシンプルなクライアントアプリも作りました。
バージョン情報
パッケージ名 | バージョン |
---|---|
@angular/core | 4.1.1 |
bootstrap | 4.0.0-alpha.6 |
rxjs | 5.3.1 |
typescript | 2.2.2 |
WebSocketとSubjectの連携イメージ
RxJSの部品は、今回 Subject を使います。Subjectは、定義したバックエンドのデータソースを Subscribe
することができ、なおかつバックエンドに対して next
によりデータを送出する能力をもっています。一方でWebSocketはコネクションが成立した後、 onmessage
イベントに対するハンドラを登録してデータ受信時のアクションを定め、 send
によってデータを送信します。この時点で Subject と WebSocketは相性が良いのではないかと考えました。実際この考えを実装した先人がいます。
上記のブログでは Subject と WebSocket を連携する実装までが提示されています。本記事では、連携部分を組み込みつつ、自作のチャットサーバをバックエンドとして、画面を作ってみるところまでやります。画面、Subject、WebSocket の連携イメージは以下のようなものです。
実装をみていきましょう。コアに近いところから順に、WebSocketService、ChatService、ChatComponent です。
WebSocketService - WebSocketを隠蔽してSubjectを提供
import {Subject, Observable, Observer} from 'rxjs/Rx'; import {Injectable} from '@angular/core'; @Injectable() export class WebSocketService { private subject: Subject<MessageEvent>; // -- ① WebSocket接続時に返す Subject connect(url: string): Subject<MessageEvent> { // -- ② このメソッドを呼び出して Subject を手に入れます if (!this.subject) { this.subject = this.create(url); } return this.subject; } private create(url: string): Subject<MessageEvent> { const ws = new WebSocket(url); // -- ③ WebSocket オブジェクトを生成します const observable = Observable.create((obs: Observer<MessageEvent>) => { // -- ④ Observable オブジェクトを生成します ws.onmessage = obs.next.bind(obs); ws.onerror = obs.error.bind(obs); ws.onclose = obs.complete.bind(obs); return ws.close.bind(ws); }); const observer = { // -- ⑤ Observer オブジェクトを生成します next: (data: Object) => { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify(data)); } }, }; return Subject.create(observer, observable); // -- ⑥ Subject を生成してconnect } }
画面の部品(Angularの世界で Component といいます)から接続要求をうけたら、Subject のオブジェクトを返すことがこのサービスの役割です。先程、Subject はデータの購読と送出をどちらも行うことができる特徴を持つと述べました。コーディングの観点で言うと「Observable も Observer も実装している」ということです。それぞれ④と⑤で用意しています。
④ Observable オブジェクトの生成では、bind関数を使ってWebSocketオブジェクトに求められる関数を実装しています。Observable
の create 時に利用できる Observer
オブジェクトの関数を使い、新しい関数を生成してWebSocketの関数の実装として利用します。
コンポーネントはこのサービスを介すことで、WebSocketの世界に足を踏み入れることなく、サーバー側とやりとりできます。今回はサーバーサイドがWebSocketでしたが、Observavle
とObserver
実装が決まれば、通信プロトコルは問わないはずですし、通信にかぎらず例えばファイルの読み書きなどでも利用できそうです。便利ですね。
ChatService - Subject を利用するためのインターフェース提供
import {Injectable} from '@angular/core'; import {Subject} from 'rxjs/Rx'; import {WebSocketService} from './websocket.service'; import {ChatMessage} from './chat/chat.message'; @Injectable() export class ChatService { private messages: Subject<ChatMessage>; private chatUrl(roomNumber: string, name: string): string { return `ws://localhost:9000/chat/stream/${roomNumber}?user_name=${name}`; // -- ① WebSocket サーバーの接続先です } constructor(private ws: WebSocketService) { } connect(roomNumber: string, name: string): Subject<ChatMessage> { // -- ② チャットの接続。 WebSocketService の connect を呼び出し、 Subject を返します。 return this.messages = <Subject<ChatMessage>>this.ws .connect(this.chatUrl(roomNumber, name)) .map((response: MessageEvent): ChatMessage => { const data = JSON.parse(response.data) as ChatMessage; return data; }); } send(name: string, message: string): void { // -- ③ メッセージの送信要求をがあったときは、WebSocketService の `next` メソッドを呼んでいるだけです this.messages.next(this.createMessage(name, message)); } private createMessage(name: string, message: string): ChatMessage { return new ChatMessage(name, message, false); } }
このサービスは、Subject が扱うメッセージのデータ型を、チャットの世界にふさわしいものに変換することが役割です。
ChatComponent
コンポーネントであり、画面の部品です。チャットの画面に使います。
import {Component, Input, OnInit, ViewEncapsulation} from '@angular/core'; import {ActivatedRoute, Params} from '@angular/router'; import {ChatService} from '../chat.service'; import {Md5} from 'ts-md5/dist/md5'; @Component({ selector: 'app-chat', templateUrl: './chat.component.html', styleUrls: ['./chat.component.css'], providers: [ChatService], encapsulation: ViewEncapsulation.None }) export class ChatComponent implements OnInit { roomNumber: string; name: string; // for html @Input() messages: ChatModel[] = new Array(); // -- ① htmlで利用するオブジェクトです constructor( private chatService: ChatService, private route: ActivatedRoute ) { } send(message: string): void { this.chatService.send( this.name, message ); } ngOnInit(): void { // -- ② 初期化時に呼び出されます this.route.params.forEach((params: Params) => { this.roomNumber = params['roomNumber']; }); this.route.queryParams.forEach((params: Params) => { this.name = params['name']; }); this.chatService.connect(this.roomNumber, this.name).subscribe(msg => { const isMe = msg.userName === this.name; this.messages.push(new ChatModel( msg.userName, msg.text, msg.systemFlag, { me: isMe, someone: !isMe }, this.color6(msg.userName) )); }); } private color6(key: string): string { const hash6 = Md5.hashStr(encodeURIComponent(key)).toString().slice(6, 12); const rgb = parseInt(hash6, 16) % 1000000; return ('000000' + rgb).slice(-6); } } class ChatModel { userName: string; text: string; systemFlag: boolean; speaker: {}; faceColor: string; constructor(userName: string, text: string, systemFlag: boolean, speaker: {}, faceColor: string) { this.userName = userName; this.text = text; this.systemFlag = systemFlag; this.speaker = speaker; this.faceColor = faceColor; } }
ngOninit
では以下のことをやっています。
- パスパラメータ、クエリパラメータから部屋番号とユーザ名を抽出します。実際にアクセスされる際のパスは
http://localhost:4200/rooms/1?name=wada
のような形になります。 - ChatService の
connect
を呼び出します。これで手に入るのは Subjectです。ここで subscribe して ChatModel
の配列に変換します。
あとは、ここで手に入った ChatModel[]
を使って画面を描画するだけです。
<div class="container"> <div appNgAutoScroll class="chat-box"> <ul class="list-group"> <li *ngFor="let msg of messages" class="list-group-item" [ngClass]="msg.speaker"> <div class="col-xs-2 face"><img src="http://placehold.jp/24/{{msg.faceColor}}/FFF/100x50.png?text={{msg.userName}}"></div> <div class="col-xs-1"></div> <div class="col-xs-9 fukidashi"><span>{{msg.text}}</span></div> </li> </ul> </div> <div class="send-box"> <div class="row"> <div class="col-10"><input #sendMessage class="form-control" (keydown.meta.enter)="send(sendMessage.value); sendMessage.value='';"></div> <div class="col-2"> <button class="btn btn-default" (click)="send(sendMessage.value); sendMessage.value='';">送信</button> </div> </div> </div> </div>
注目していただきたいのは<ul><li>
の *ngFor="let msg of messages"
です。messages
が chat.component.ts で定義した ChatModel 配列が格納されている変数です。画面でこれを使い、チャットユーザの名前やメッセージ内容を読み出すことができるというわけです。
ここまでで、subscribe
と send
の実装さえ決まれば、Subject を利用しての WebSocket サーバーとのやりとりが可能になることがわかりました。
メッセージやりとり以外の Tips
今回の主旨とは直接関係はありませんが、Angularを使って画面を実装する上で便利だなと思ったことなどをメモします。
チャットメッセージが伸びるに従って自動スクロール
やりとりの内容が増えても、最新のメッセージが表示されるように自動でスクロールして欲しいと思いました。Angularの世界ではディレクティブで実現できます。今回はangular2-auto-scrollを使わせていただきました。ただし、執筆時点でDeprecatedとなっており、現在はngx-auto-scrollがメンテナンスされているようです。本記事のソースコードはangular2-auto-scrollをAngular4でも利用できるよう組み込ませて使わせていただきました。
使い方はとても簡単で、app.module.ts
のdeclarations
に当該ディレクティブを宣言した上で、使用したいコンポーネントのHTMLにディレクティブ名を宣言するだけです。今回の場合はAppNgAutoScrollDirective
という名前にしています。
import {AppNgAutoScrollDirective} from '../lib/ng-auto-scroll'; @NgModule({ declarations: [ AppComponent, ChatComponent, LobbyComponent, AppNgAutoScrollDirective ], imports: [ BrowserModule, ...
<div class="container"> <div appNgAutoScroll class="chat-box"> <ul class="list-group"> ...
チャットのように、HTMLのコンテンツがどんどん増えていくような場合に、スクロールする手間を省きたいときに使えます。
自分の発言を色付け
冒頭のgifで色がつぶれてわかりにくいですが以下のようなイメージです。
スタイル自体は、class=me
に対して背景色を設定しているだけです。属性を設定するかしないかについてAngularで表現しています。
<li *ngFor="let msg of messages" class="list-group-item" [ngClass]="msg.speaker"> ... </li>
[ngClass]="msg.speaker"
を使うことで設定しています。msgは ChatModel でした。その speaker は、objectで、発言者の名前が自分と一致しているかどうかを判定してobjectを作成します。Angularは、objectのkey-valueをみて、valueがtrueだった場合、そのkeyをclassとして設定してくれます。今回のように、「特定の条件が成立したときだけスタイルを設定する」といったシーンで役に立ちます。
this.messages.push(new ChatModel( msg.userName, msg.text, msg.systemFlag, { me: isMe, someone: !isMe }, this.color6(msg.userName) ));
結果、発言者が自分である場合、 HTML に class=me
が反映されます。
Command + Enter でもメッセージ送信できるようにする
HTMLで、(keydown.meta.enter)
というイベント時に発火するchat.component.ts
の処理を記載するだけです。Angularが記法を用意してくれていたのでとても簡単でした。jQueryだとそれなりのコードを書いていた記憶があります。
<div class="col-10"><input #sendMessage class="form-control" (keydown.meta.enter)="send(sendMessage.value); sendMessage.value='';"></div>
おまけ:動かすには
バックエンドのAkkaと、Angularを起動すれば動きます。前もって以下が必要です。
- Java8
- SBT
- node.js
- npm
$ git clone git@github.com:cm-wada-yusuke/chatserver-play-websocket-akka-stream.git $ cd chatserver-play-websocket-akka-stream $ bin/activator run
git clone git@github.com:cm-wada-yusuke/angular-websocket-chat.git cd angular-websocket-chat npm install ng serve
localhost:4200
へアクセスすると部屋番号と名前を入力する画面が表示されます。
ソースコード
- cm-wada-yusuke/chatserver-play-websocket-akka-stream
- cm-wada-yusuke/angular-websocket-chat: websocket chat client on angular